-
Notifications
You must be signed in to change notification settings - Fork 739
Detect Pong from MCP Client and skip Session ID validation #539
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Detect Pong from MCP Client and skip Session ID validation #539
Conversation
WalkthroughSkip empty JSON-RPC ping/pong responses in StreamableHTTPServer POST handling by detecting messages with an ID and empty Result/Error via a new isJSONEmpty helper; adjust isSamplingResponse formatting and add tests validating pong handling. Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Suggested reviewers
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests
Tip 👮 Agentic pre-merge checks are now available in preview!Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.
Please see the documentation for more information. Example: reviews:
pre_merge_checks:
custom_checks:
- name: "Undocumented Breaking Changes"
mode: "warning"
instructions: |
Pass/fail criteria: All breaking changes to public APIs, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints must be documented in the "Breaking Change" section of the PR description and in CHANGELOG.md. Exclude purely internal or private changes (e.g., code not exported from package entry points or explicitly marked as internal). Please share your feedback with us on this Discord post. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
🧹 Nitpick comments (2)
server/streamable_http.go (1)
249-251
: LGTM with minor edge case consideration.The updated sampling response detection logic correctly differentiates from ping responses. However, consider handling the edge case where both
Result
andError
are nil - such messages would bypass both ping and sampling detection.Consider adding explicit validation:
isSamplingResponse := jsonMessage.Method == "" && jsonMessage.ID != nil && - (jsonMessage.Result != nil || jsonMessage.Error != nil) + (jsonMessage.Result != nil || jsonMessage.Error != nil) && + !isPingResponseserver/streamable_http_test.go (1)
897-990
: Improve test coverage and assertions.The test structure is good and covers the main scenarios, but could be enhanced:
Missing positive assertions: Tests only verify that sampling errors don't occur, but don't confirm that ping responses are actually handled correctly.
Unrealistic scenario: The third test case with empty error may not represent a realistic ping/pong scenario according to the MCP spec.
Consider these improvements:
+ // First verify a real sampling response still triggers the error + t.Run("Real sampling response should still require session ID", func(t *testing.T) { + samplingResponse := map[string]any{ + "jsonrpc": "2.0", + "id": 126, + "result": map[string]any{"content": "test"}, + } + + resp, err := postJSON(server.URL, samplingResponse) + if err != nil { + t.Fatalf("Failed to send sampling response: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + t.Error("Expected sampling response to fail without session ID") + } + })Also consider verifying response content more thoroughly:
if resp.StatusCode != http.StatusOK { t.Errorf("Expected status 200 for pong response, got %d. Body: %s", resp.StatusCode, bodyStr) } + + // Verify response body is empty (as expected for ping responses) + if len(bodyBytes) > 0 { + t.Errorf("Expected empty response body for ping, got: %s", bodyStr) + }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
server/streamable_http.go
(1 hunks)server/streamable_http_test.go
(1 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (1)
server/streamable_http.go (1)
mcp/types.go (1)
Result
(245-249)
2d6f1a2
to
b8977cd
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
♻️ Duplicate comments (1)
server/streamable_http.go (1)
267-274
: Explicitly acknowledge pong and make detection resilient to meta-only payloads
- Early return risks middleware/proxies misinterpreting the response. Send 200 OK explicitly.
- Treat result/error objects containing only "_meta" as empty to match tolerant clients per prior feedback.
Apply:
- // detect empty ping response, skip session ID validation - isPingResponse := jsonMessage.Method == "" && jsonMessage.ID != nil && - (isJSONEmpty(jsonMessage.Result) && isJSONEmpty(jsonMessage.Error)) - - if isPingResponse { - return - } + // detect pong response (no method, has id, and empty/omitted result or error) + isPingResponse := jsonMessage.Method == "" && jsonMessage.ID != nil && + (isJSONEmpty(jsonMessage.Result) || isJSONEmpty(jsonMessage.Error)) + + if isPingResponse { + w.WriteHeader(http.StatusOK) + return + }And update isJSONEmpty as suggested below to consider {"_meta": ...} empty as well. Also, please add a small debug/info log when a pong is detected for observability. Would you like me to add a follow-up commit with tests for the {"_meta":...} case?
🧹 Nitpick comments (2)
server/streamable_http.go (1)
951-997
: Replace byte-scanning with parse-based emptiness; drop unnecessary imports
- Current byte/rune scanning is brittle and doesn’t recognize {"_meta":...} as empty.
- JSON whitespace is ASCII; using unicode.IsSpace on bytes is unnecessary.
- Simplify by unmarshalling objects and checking keys.
Apply:
-// isJSONEmpty reports whether the provided JSON value is "empty": -// - null -// - empty object: {} -// - empty array: [] -// It also treats nil/whitespace-only input as empty. -// It does NOT treat 0, false, "" or non-empty composites as empty. -func isJSONEmpty(data json.RawMessage) bool { - if len(data) == 0 { - return true - } - - trimmed := bytes.TrimSpace(data) - if len(trimmed) == 0 { - return true - } - - switch trimmed[0] { - case '{': - if len(trimmed) == 2 && trimmed[1] == '}' { - return true - } - for i := 1; i < len(trimmed); i++ { - if !unicode.IsSpace(rune(trimmed[i])) { - return trimmed[i] == '}' - } - } - case '[': - if len(trimmed) == 2 && trimmed[1] == ']' { - return true - } - for i := 1; i < len(trimmed); i++ { - if !unicode.IsSpace(rune(trimmed[i])) { - return trimmed[i] == ']' - } - } - - case '"': // treat "" as not empty - return false - - case 'n': // null - return len(trimmed) == 4 && - trimmed[1] == 'u' && - trimmed[2] == 'l' && - trimmed[3] == 'l' - } - return false -} +// isJSONEmpty reports whether the provided JSON value is "empty": +// - null +// - empty object: {} +// - object with only "_meta" key +// - omitted (nil) +// NOTE: We intentionally do NOT treat []/""/0/false as empty. +func isJSONEmpty(data json.RawMessage) bool { + if len(data) == 0 { + return true + } + // null + if string(data) == "null" { + return true + } + // object {} + var obj map[string]json.RawMessage + if err := json.Unmarshal(data, &obj); err == nil { + if len(obj) == 0 { + return true + } + if len(obj) == 1 { + _, onlyMeta := obj["_meta"] + return onlyMeta + } + } + return false +}And clean up imports:
-import ( - "bytes" +import ( "context" "encoding/json" "fmt" "io" "mime" "net/http" "net/http/httptest" "os" "strings" "sync" "sync/atomic" "time" - "unicode"server/streamable_http_test.go (1)
897-991
: Good coverage; add a meta-only pong caseAdd a subtest for {"result":{"_meta":{...}}} to ensure tolerant handling, and to lock in the helper behavior.
Suggested addition:
t.Run("Pong response with empty result should not be treated as sampling response", func(t *testing.T) { // ... }) + t.Run("Pong response with only _meta in result should not be treated as sampling response", func(t *testing.T) { + pongResponse := map[string]any{ + "jsonrpc": "2.0", + "id": 126, + "result": map[string]any{ + "_meta": map[string]any{"client": "test"}, + }, + } + resp, err := postJSON(server.URL, pongResponse) + if err != nil { + t.Fatalf("Failed to send pong response: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Fatalf("Expected status 200, got %d. Body: %s", resp.StatusCode, string(bodyBytes)) + } + })
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
server/streamable_http.go
(4 hunks)server/streamable_http_test.go
(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
server/streamable_http_test.go (2)
server/server.go (1)
NewMCPServer
(334-362)server/streamable_http.go (1)
NewTestStreamableHTTPServer
(945-949)
Description
Fixes #<issue_number> (if applicable)
Type of Change
Checklist
MCP Spec Compliance
Additional Information
This fix ensures compliance with the MCP ping/pong specification:
Previously, when WithHeartbeatInterval was enabled in the HTTP Streamable MCP server, Cursor AI and Claude Desktop got a 400 error - "Missing session ID for sampling response" - because the
Pong
response was incorrectly interpreted as aSamplingResponse
.Without heartbeat support, Cursor disconnects from MCP after a 5-minute timeout.
Summary by CodeRabbit
Bug Fixes
Tests